Flutter full app 1. Music Player: create a simple Flutter music player app
Flutter | Dart | just_audio |
---|---|---|
1.22.6 | 2.10.5 | 0.6.13 |
Today I decided to start a little project to show how to create a full app in Flutter, from an initial proof of concept, to testing, to improving UX and UI, to (light) project management. Hopefully, the app will be integrated with additional features as I publish more articles.
The idea is to create an audio (music/podcast) player that you can use to play audio from the internet. In this first article, we start by using just_audio, a package for playing audio by Ryan Heise.
Setup the project
We start by creating the Flutter project with
flutter create music_player
then we change the app version, in pubspec.yaml
from 1.0.0+1
to 0.0.1+1
, because we will use 1.0.0
for the first full release. While we are in pubspec.yaml
we can also add the just_audio
package dependency:
# Play music files
just_audio: ^0.6.12
Add a play button
Now it is time to start with coding. The very first goal is to have a button that, when pressed, starts playing music. So, create a new folder in lib
, called screens
. This is the folder where most of the files related to the UI will reside. In screens
create a Dart file called player.dart
. Create a new stateful widget, with a private property called _audioPlayer
of type AudioPlayer
, which is defined in just_audio.
In void iniState()
we are going to initialize the audio player, and in void dispose()
we are going to dispose of it, as per documentation.
So this is the code up until now:
import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';
class Player extends StatefulWidget {
@override
_PlayerState createState() => _PlayerState();
}
class _PlayerState extends State<Player> {
AudioPlayer _audioPlayer;
@override
void initState() {
super.initState();
_audioPlayer = AudioPlayer();
// Set a sequence of audio sources that will be played by the audio player.
_audioPlayer
.setAudioSource(ConcatenatingAudioSource(children: [
AudioSource.uri(Uri.parse(
"https://archive.org/download/IGM-V7/IGM%20-%20Vol.%207/25%20Diablo%20-%20Tristram%20%28Blizzard%29.mp3")),
AudioSource.uri(Uri.parse(
"https://archive.org/download/igm-v8_202101/IGM%20-%20Vol.%208/15%20Pokemon%20Red%20-%20Cerulean%20City%20%28Game%20Freak%29.mp3")),
AudioSource.uri(Uri.parse(
"https://scummbar.com/mi2/MI1-CD/01%20-%20Opening%20Themes%20-%20Introduction.mp3")),
]))
.catchError((error) {
// catch load errors: 404, invalid url ...
print("An error occured $error");
});
}
@override
void dispose() {
_audioPlayer.dispose();
super.dispose();
}
}
An AudioSource
can be a remote file, like in the code above, or a local file.
We need a button to be able to start and stop the audio player. We are going to use a method to discriminate between the different states of the app: playing, paused, completed.
Create a private method Widget _playerButton(PlayerState playerState)
.
PlayerState
is a class defined by just_audio that holds information about, you guess it, the state of the audio player. More in details it
/// Encapsulates the playing and processing states. These two states vary
/// orthogonally, and so if [processingState] is [ProcessingState.buffering],
/// you can check [playing] to determine whether the buffering occurred while
/// the player was playing or while the player was paused.
This is the code we are going to add to it.
Widget _playerButton(PlayerState playerState) {
// 1
final processingState = playerState?.processingState;
if (processingState == ProcessingState.loading ||
processingState == ProcessingState.buffering) {
// 2
return Container(
margin: EdgeInsets.all(8.0),
width: 64.0,
height: 64.0,
child: CircularProgressIndicator(),
);
} else if (_audioPlayer.playing != true) {
// 3
return IconButton(
icon: Icon(Icons.play_arrow),
iconSize: 64.0,
onPressed: _audioPlayer.play,
);
} else if (processingState != ProcessingState.completed) {
// 4
return IconButton(
icon: Icon(Icons.pause),
iconSize: 64.0,
onPressed: _audioPlayer.pause,
);
} else {
// 5
return IconButton(
icon: Icon(Icons.replay),
iconSize: 64.0,
onPressed: () => _audioPlayer.seek(Duration.zero,
index: _audioPlayer.effectiveIndices.first),
);
}
}
- extracts the processing state, which is one among idle, loading, buffering, ready, and completed;
- if the player is in a temporary state, like loading and buffering, the button is replaced by a loading indicator;
- otherwise, if the audio player is not playing anything, which means that it is either paused or not started yet, the button is a play button that invokes the
play()
method on the audio player; - otherwise, if the state is not completed, then the audio player is playing one of the audio sources and the button is a pause button that invokes
pause()
on the audio player; - finally, if all the above are not true, then the player has finished playing the sequence of audio sources, and it is still playing nothing, and the button is a replay button that moves the head of the player to the very beginning of the first audio source. Given that the player is playing, but it reached the end of the sequence, the music will start immediately, without the need to invoke an additional
play()
.
The only thing missing now is the build
method:
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: StreamBuilder<PlayerState>(
stream: _audioPlayer.playerStateStream,
builder: (context, snapshot) {
final playerState = snapshot.data;
return _playerButton(playerState);
},
),
),
);
}
just_audio provides some handy streams to emit the state and data to listeners. One of them is playerStateStream
, which emits PlayerState
objects, just what is needed in _playerButton
. A StreamBuilder
is the perfect widget to handle the state and build the button.
Now we need to refer to this widget in main.dart
.
// main.dart
import 'package:flutter/material.dart';
import 'package:music_player/screens/player.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Player(),
);
}
}
Add other buttons
Run the app, and try it. It works. But it is a bit sad: just a button in the middle of the screen.
We will need to add more buttons to make it more interesting. First of all, we are going to separate the buttons UI from the player screen. We might want to use the same UI in some other screens in the future.
Create a new folder in screens
called commons
, and add in it a new file called player_buttons.dart
. The widget in this file is a StatelessWidget
, because it will use streams to handle the state.
Player
needed to be stateful because it needs to handle initialization and disposalof the audio player, but in this widget we can just inject the player.
In the future, we are going to change how we initialize and dispose of the audio player, and how we inject it into those widgets that need it, but for now, this is enough.
Move _playerButton
to the new file and rename it _playPauseButton
, to avoid confusion when we are going to add more buttons.
We want to create:
- a shuffle button, which will enable or disable shuffle mode, using
_audioPlayer.shuffle()
to change the order of the audio sources that did not play yet, and_audioPlayer.setShuffleModeEnabled(true)
to set the mode of the player to shuffle. - a previous button, that will be enabled if there is a previous audio source, and will load that source when pressed, using
_audioPlayer.seekToPrevious()
; - a next button, that will be enabled if there is a next audio source, and will load that source when pressed, using
_audioPlayer.seekToNext()
; - a loop button, which will cycle among not looping, looping one audio source, and looping the entire sequence of audio sources, using
_audioPlayer.setLoopMode(_)
.
AudioPlayer
provides streams for 1 and 4. For 2 and 3 we will need to observe the SequenceState
that will emit every time either the sequence of audio sources changes, for instance because the shuffle mode changed, or the current playing audio source changes.
In the build
function of PlayerButtons
add
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
StreamBuilder<bool>(
stream: _audioPlayer.shuffleModeEnabledStream,
builder: (context, snapshot) {
return _shuffleButton(context, snapshot.data ?? false);
},
),
StreamBuilder<SequenceState>(
stream: _audioPlayer.sequenceStateStream,
builder: (_, __) {
return _previousButton();
},
),
StreamBuilder<PlayerState>(
stream: _audioPlayer.playerStateStream,
builder: (_, snapshot) {
final playerState = snapshot.data;
return _playPauseButton(playerState);
},
),
StreamBuilder<SequenceState>(
stream: _audioPlayer.sequenceStateStream,
builder: (_, __) {
return _nextButton();
},
),
StreamBuilder<LoopMode>(
stream: _audioPlayer.loopModeStream,
builder: (context, snapshot) {
return _repeatButton(context, snapshot.data ?? LoopMode.off);
},
),
],
);
}
We added a row, and for each button, we add a stream builder. The first observers the shuffle state of the audio player, the second and fourth observe the sequence state, and the fifth observes the state of the loop mode. The third one is the play button we already build in the previous section.
Now we only need to create four missing methods. The first method is the one that creates a widget for the shuffle mode.
Widget _shuffleButton(BuildContext context, bool isEnabled) {
return IconButton(
icon: isEnabled
? Icon(Icons.shuffle, color: Theme.of(context).accentColor)
: Icon(Icons.shuffle),
onPressed: () async {
final enable = !isEnabled;
if (enable) {
await _audioPlayer.shuffle();
}
await _audioPlayer.setShuffleModeEnabled(enable);
},
);
}
The second and the third methods create the previous and next buttons.
Widget _previousButton() {
return IconButton(
icon: Icon(Icons.skip_previous),
onPressed: _audioPlayer.hasPrevious ? _audioPlayer.seekToPrevious : null,
);
}
Widget _nextButton() {
return IconButton(
icon: Icon(Icons.skip_next),
onPressed: _audioPlayer.hasNext ? _audioPlayer.seekToNext : null,
);
}
Every time the stream _audioPlayer.sequenceStateStream
emits a new value, we check whether the current playing source has a previous and a next source in the sequence, and, if so, we set onPressed
to the relevant method of _audioPlayer
.
The fourth and last method is the one that creates the loop button.
Widget _repeatButton(BuildContext context, LoopMode loopMode) {
final icons = [
Icon(Icons.repeat),
Icon(Icons.repeat, color: Theme.of(context).accentColor),
Icon(Icons.repeat_one, color: Theme.of(context).accentColor),
];
const cycleModes = [
LoopMode.off,
LoopMode.all,
LoopMode.one,
];
final index = cycleModes.indexOf(loopMode);
return IconButton(
icon: icons[index],
onPressed: () {
_audioPlayer.setLoopMode(
cycleModes[(cycleModes.indexOf(loopMode) + 1) % cycleModes.length]);
},
);
}
Every time the button is pressed, we cycle through a list of loop modes: off, all, and one.
Now the app is more interesting. But it is still missing a lot.
In the next article of the series, we will start improving the project repositoryand we will add the list of audio sources, or playlist, that we are playing.
This is the full code for this article.
// main.dart
import 'package:flutter/material.dart';
import 'package:music_player/screens/player.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Player(),
);
}
}
// player.dart
import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';
import 'package:music_player/screens/commons/player_buttons.dart';
class Player extends StatefulWidget {
@override
_PlayerState createState() => _PlayerState();
}
class _PlayerState extends State<Player> {
AudioPlayer _audioPlayer;
@override
void initState() {
super.initState();
_audioPlayer = AudioPlayer();
_audioPlayer
.setAudioSource(ConcatenatingAudioSource(children: [
AudioSource.uri(Uri.parse(
"https://archive.org/download/IGM-V7/IGM%20-%20Vol.%207/25%20Diablo%20-%20Tristram%20%28Blizzard%29.mp3")),
AudioSource.uri(Uri.parse(
"https://archive.org/download/igm-v8_202101/IGM%20-%20Vol.%208/15%20Pokemon%20Red%20-%20Cerulean%20City%20%28Game%20Freak%29.mp3")),
AudioSource.uri(Uri.parse(
"https://scummbar.com/mi2/MI1-CD/01%20-%20Opening%20Themes%20-%20Introduction.mp3")),
]))
.catchError((error) {
// catch load errors: 404, invalid url ...
print("An error occured $error");
});
}
@override
void dispose() {
_audioPlayer.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: PlayerButtons(_audioPlayer),
),
);
}
}
// player_buttons.dart
import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';
class PlayerButtons extends StatelessWidget {
const PlayerButtons(this._audioPlayer, {Key key}) : super(key: key);
final AudioPlayer _audioPlayer;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
StreamBuilder<bool>(
stream: _audioPlayer.shuffleModeEnabledStream,
builder: (context, snapshot) {
return _shuffleButton(context, snapshot.data ?? false);
},
),
StreamBuilder<SequenceState>(
stream: _audioPlayer.sequenceStateStream,
builder: (_, __) {
return _previousButton();
},
),
StreamBuilder<PlayerState>(
stream: _audioPlayer.playerStateStream,
builder: (_, snapshot) {
final playerState = snapshot.data;
return _playPauseButton(playerState);
},
),
StreamBuilder<SequenceState>(
stream: _audioPlayer.sequenceStateStream,
builder: (_, __) {
return _nextButton();
},
),
StreamBuilder<LoopMode>(
stream: _audioPlayer.loopModeStream,
builder: (context, snapshot) {
return _repeatButton(context, snapshot.data ?? LoopMode.off);
},
),
],
);
}
Widget _playPauseButton(PlayerState playerState) {
final processingState = playerState?.processingState;
if (processingState == ProcessingState.loading ||
processingState == ProcessingState.buffering) {
return Container(
margin: EdgeInsets.all(8.0),
width: 64.0,
height: 64.0,
child: CircularProgressIndicator(),
);
} else if (_audioPlayer.playing != true) {
return IconButton(
icon: Icon(Icons.play_arrow),
iconSize: 64.0,
onPressed: _audioPlayer.play,
);
} else if (processingState != ProcessingState.completed) {
return IconButton(
icon: Icon(Icons.pause),
iconSize: 64.0,
onPressed: _audioPlayer.pause,
);
} else {
return IconButton(
icon: Icon(Icons.replay),
iconSize: 64.0,
onPressed: () => _audioPlayer.seek(Duration.zero,
index: _audioPlayer.effectiveIndices.first),
);
}
}
Widget _shuffleButton(BuildContext context, bool isEnabled) {
return IconButton(
icon: isEnabled
? Icon(Icons.shuffle, color: Theme.of(context).accentColor)
: Icon(Icons.shuffle),
onPressed: () async {
final enable = !isEnabled;
if (enable) {
await _audioPlayer.shuffle();
}
await _audioPlayer.setShuffleModeEnabled(enable);
},
);
}
Widget _previousButton() {
return IconButton(
icon: Icon(Icons.skip_previous),
onPressed: _audioPlayer.hasPrevious ? _audioPlayer.seekToPrevious : null,
);
}
Widget _nextButton() {
return IconButton(
icon: Icon(Icons.skip_next),
onPressed: _audioPlayer.hasNext ? _audioPlayer.seekToNext : null,
);
}
Widget _repeatButton(BuildContext context, LoopMode loopMode) {
final icons = [
Icon(Icons.repeat),
Icon(Icons.repeat, color: Theme.of(context).accentColor),
Icon(Icons.repeat_one, color: Theme.of(context).accentColor),
];
const cycleModes = [
LoopMode.off,
LoopMode.all,
LoopMode.one,
];
final index = cycleModes.indexOf(loopMode);
return IconButton(
icon: icons[index],
onPressed: () {
_audioPlayer.setLoopMode(
cycleModes[(cycleModes.indexOf(loopMode) + 1) % cycleModes.length]);
},
);
}
}
Leave a comment